Pro Entity Framework Core 2 for ASP.NET Core MVC 翻译

第 5 章 运动商店:存储数据

作者:Adam Freeman
翻译:陈广
日期:2018-12-11


本章,我演示了如何将 SportsStore 应用程序的数据存储在数据库中。我演示了如何在项目中添加 Entity Framework Core,如何准备数据模型,如何创建并使用数据库以及如何调整应用程序,使其能够高效地进行 SQL 查询。我还描述了在向项目中添加 Entity Framework Core 时最有可能遇到的问题,并解释了如何解决这些问题。

准备本章

我继续使用第4章创建的 SportsStore 项目。本章不需要更改。打开一个命令提示或 PowerShell 窗口,导航至 SportsStore 项目文件夹(包含 libman.json 文件的那个),并使用dotnet run启动应用程序。将浏览器导航至 http://localhost:5000,您将看到如图5-1所示的内容。您可以使用 HTML 表单存储 Product 对象,但当应用程序停止或重启时它们将丢失,因为数据是存储在内存中的。

提示:你可以在 https://github.com/apress/pro-ef-core2-for-asp.net-core-mvc 中下载 SportsStore 项目以及本书其它章节的所有项目。

图5-1 运行示例应用程序

配置 Entity Framework Core

Visual Studio 为 ASP.NET Core 项目创建的默认配置包括运行 Entity Framework Core 应用程序所需的 NuGet 包。添加管理数据库的命令行工具需要一个单独的包,并且必须手动安装。

在【解决方案资源管理器】窗口中右键单击 SportsStore 项,在弹出菜单中选择【编辑 SportsStore.csproj】,并添加如清单5-1所示的配置元素。

清单 5-1:SportsStore 文件夹下的 SportsStore.csproj 文件,添加程序包

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>netcoreapp2.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
        <Folder Include="wwwroot\" />
    </ItemGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.3" />
        <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet"
            Version="2.0.0" />
    </ItemGroup>
</Project>

包含命令行工具的程序包必须使用DotNetCliToolReference元素手动添加。清单中显示的程序包包含了dotnet ef命令,这些命令用于管理使用 Entity Framework Core 的项目中的数据库。

译者注:.NET 2.1 后,已经内置了命令行工具。所以如果使用的是 .NET Core 2.1 以后的版本,无需以上步骤

配置 Entity Framework Core 日志消息

理解 Entity Framework Core 发送到数据库服务器的 SQL 查询和命令是非常重要的,即使项目所存放的数据非常少。为了配置 Entity Framework Core 以生成将显示它使用的 SQL 查询的日志消息,我使用 ASP.NET 配置文件项模板将一个名为appSetings.json的文件添加到运动员存储文件夹中,并添加了如清单5-2所示的配置语句。

为了配置实体框架核心以生成将显示它使用的SQL查询的日志消息,我使用 ASP.NET 【应用设置文件】模板将一个名为 appsettings.json 的文件添加到 SportsStore 文件夹中,并添加了如清单5-2所示的配置语句。

清单 5-2:SportsStore 文件夹下的 appsettings.json 文件的内容

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=_CHANGE_ME;Trusted_Connection=True;MultipleActiveResultSets=true"
  },
  "Logging": {
    "LogLevel": {
      "Default": "None",
      "Microsoft.EntityFrameworkCore": "Information"
    }
  }
}

Visual Studio 使用此类文件的默认内容包含一个用于数据库的连接字符串,稍后我将对此进行更改。清单5-2中高亮显示的新添加内容将默认日志级别设置为None,这将禁用所有日志消息。然后,将 Microsoft.EntityFrameworkCore 包重新设置为Information,该设置将提供 Entity Framework Core 使用的 SQL 的详细信息。在实际项目中,您不必禁用所有其它日志信息,但是这种组合将使我们更容易学习这些例子。

准备数据模型

在下面的部分中,我准备了已存在于 SportsStore 项目中的数据模型,以便与 Entity Framework Core 一起使用。

定义主键属性

在数据库中存储数据,Entity Framework Core 需要能够唯一地标识每个对象,从而要选择一个属性用作主键。对于大多数项目来说,定义主键的最简单的方法是在数据模型类中添加一个名为Idlong类型属性。,如清单5-3所示。

清单 5-3:Models 文件夹下的 Product.cs 文件,添加一个主键属性

namespace SportsStore.Models
{
    public class Product
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public string Category { get; set; }
        public decimal PurchasePrice { get; set; }
        public decimal RetailPrice { get; set; }
    }
}

此方法意味着 Entity Framework Core 将配置数据库,以便数据库服务器生成主键值,这样您无需担心重复问题。使用long值可以确保有大量的主键值可用,这意味着大多数项目将能够无限期地存储数据,而不必担心键值的耗尽。

创建数据库 Context 类

Entity Framework Core 依赖于一个数据库 context 类来为应用程序提供对数据库中数据的访问。为给示例应用程序提供一个 context,我在 Models 文件夹下添加了一个名为 DataContext.cs 的类,代码如清单5-4所示。

清单 5-4:Models 文件夹下的 DataContext.cs 文件的内容

using Microsoft.EntityFrameworkCore;

namespace SportsStore.Models
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> opts) : base(opts) { }
        public DbSet<Product> Products { get; set; }
    }
}

当您使用 Entity Framework Core 存储如 SportsStore 应用程序这样简单的数据模型时,数据库 context 类也相应地很简单,尽管在后面的章节中,随着数据模型变得更加复杂,这种情况将发生变化。目前,数据库 context 类有三个重要的特性。

第一个特性是基类DbContext,它定义于Microsoft.EntityFrameworkCore命名空间。使用DbContext作为基类可以生成数据库 context 并提供对 Entity Framework Core 功能的访问。

第二个特性是构造器接收一个DbContextOptions<T>对象(T是 context 类),必须使用base关键字将其传递给基类的构造函数,如下所示:

public DataContext(DbContextOptions<DataContext> opts) : base(opts) { }

构造函数参数将为 Entity Framework Core 提供连接到数据库服务器所需的配置信息。如果不定义构造函数参数或不传递对象,则会收到错误。

第三个特性是类型为DbSet<T>的属性,其中T是准备存储在数据库中存储的类。

public DbSet<Product> Products { get; set; }

数据模型类是Product,所有清单5-4中,属性返回的是DbSet<Product>对象。必须用getset子句定义属性。set子句允许 Entity Framework Core 分配一个对象,该对象提供了对数据的便捷访问。get子句为应用程序的其余部分提供了对该数据的访问。

更新存储库实现

下一步是更新存储库实现类,这样就可以通过上一节定义的 context 类来访问数据,如清单5-5所示。

清单 5-5:Models 文件夹下的 DataRepository.cs 文件,使用 Context 类

using System.Collections.Generic;

namespace SportsStore.Models
{
    public class DataRepository : IRepository
    {
        //private List<Product> data = new List<Product>();
        private DataContext context;

        public DataRepository(DataContext ctx) => context = ctx;

        public IEnumerable<Product> Products => context.Products;

        public void AddProduct(Product product)
        {
            this.context.Products.Add(product);
            this.context.SaveChanges();
        }
    }
}

在 ASP.NET Core MVC 应用程序中,使用依赖注入管理对数据 context 对象的访问,我在DataRepository类中添加了一个构造器以接收DataContext对象,它将在运行时通过依赖注入提供。

存储库接口定义的Products属性可以通过返回 context 类定义的DbSet<Product>属性来实现。类似地,AddProduct方法可以很容易地实现,因为DbSet<Product>对象定义了一个Add方法,该方法接受Product对象并持久地存储它们。

最重要的更改是对SaveChanges方法的调用,该方法告诉 Entity Framework Core 向数据库发送任意等待的操作 —— 比如请求Add方法以向数据库存储数据。

准备数据库

在接下来的部分中,我将完成配置 SportsStore 应用程序以描述我想要使用的数据库的过程,然后请 Entity Framework Core 来创建它。这被称为代码先行项目,就是先创建一个或多个 C# 类,然后使用它们创建和配置数据库。另一种方案被称为数据库先行项目,即从已存数据库创建数据模型 —— 我将在17和18章描述此过程。

配置连接字符串

Entity Framework Core 依赖于连接字符串来提供有关如何与 context 类使用的数据库服务器联系的详细信息。连接字符串的格式因所使用的数据库服务器而不同,但通常包括数据库服务器的服务器名称和网络端口、数据库名称和身份验证凭据。连接字符串是在 appsettings.json 文件中定义的,在清单5-6中,我已经为 SportsStore 数据库定义了连接字符串。本书使用了 LocalDB 版本的 SQL Server,它专为开发人员设计,不需要任何配置或凭据。

提示:必须确保连接字符串位于单个未中断的行上。书籍页的固定宽度使得很难显示连接字符串,但如果将连接字符串拆分为多行以便于阅读,则会出现错误。

连接字符串的格式特定于每个数据库服务器。对于清单5-6中的连接字符串,有四个配置属性,如表5-1所述。

清单 5-6:SportsStre 文件夹下的 appsettings.json 文件,添加一个连接字符串

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=SportsStore;Trusted_Connection=True;MultipleActiveResultSets=true"
  },
  "Logging": {
    "LogLevel": {
      "Default": "None",
      "Microsoft.EntityFrameworkCore": "Information"
    }
  }
}

表 5-1:LocalDB 连接字符串的四个配置属性

名称 描述
Server 此属性指定 Entity Framework Core 将要连接的数据库服务器的名称。对于 LocalDB 来说,此值为(localdb)\\MSSQLLocalDB,无需更多配置就可以连接至数据库服务器了。
Database 此属性指定 Entity Framework Core 将要使用的数据库的名称。在清单5-6中,我删除了 Visual Studio 添加到文件中的占位符,并指定 SportsStore 作为名称。
Trusted_Connection 当设置为true时,Entity Framework Core 将使用 Windows 帐户凭据与数据库服务器进行身份验证。此属性对于 LocalDB 并不是必需的,即使 Visual Studio 在创建 appsettings.json 文件时默认添加它。
MultipleActiveResultSets 此属性配置到数据库服务器的连接,以便同时读取来自多个查询的结果。

配置数据库提供程序和 Context 类

我向Startup类添加了如清单5-7所示的配置语句,以告诉 Entity Framework Core 如何使用连接字符串、应该使用哪个数据库提供程序以及如何管理 context 类。

清单 5-7:SportsStore 文件夹下的 Startup.cs 文件,配置 Entity Framework Core

using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace SportsStore
{
    public class Startup
    {
        public Startup(IConfiguration config) => Configuration = config;

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            services.AddTransient<IRepository, DataRepository>();
            string conString = Configuration["ConnectionStrings:DefaultConnection"];
            services.AddDbContext<DataContext>(options =>
                options.UseSqlServer(conString));
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

构造函数以及Configuration属性用于访问 appsettings.json 文件中的配置数据,以便读取连接字符串。AddDbContext<T>扩展方法用于设置 context 类并告诉 Entity Framework Core 使用哪个数据库提供程序(本例使用UseSqlServer方法,但每个数据库提供程序使用的方法不同)以及提供连接字符串。

请注意,我还更改了将依赖注入配置为IRepository接口的方法,如下所示:

services.AddTransient<IRepository, DataRepository>();

在第4章中,我使用AddSingleton方法来确保使用单个DataRepository对象来解析IRepository接口上的所有依赖项,这很重要,因为应用程序数据存储在一个List中,并且我希望始终使用相同的对象。既然现在使用的是 Entity Framework Core,就应该切换到AddTransient方法,该方法确保每次解析对IRepository的依赖时都会创建一个新的DataRepository对象。这一点很重要,因为 Entity Framework Core 希望为 ASP.NET Core MVC 应用程序中的每个 HTTP 请求创建一个新的 context 对象。

创建数据库

上一节告诉了 Entity Framework Core 我需要存储什么类型的数据以及如何连接数据库服务器。接下来是创建数据库。

Entity Framework Core 通过一个叫 迁移(migrations) 的功能来管理数据库,它们是创建或修改数据库以使其与数据模型同步的一组变迁(我将在第13章中详细描述这些变迁)。要创建设置数据库的迁移,请打开一个新的命令提示符或 PowerShell 窗口,导航到 SportsStore 项目文件夹(包含 libman.json 文件的文件夹),并运行清单5-8所示的命令。

清单 5-8:创建一个迁移

dotnet ef migrations add Initial

dotnet ef命令访问清单5-1中添加的包中的功能。migrations add参数告诉 Entity Framework Core 创建新的迁移,最后一个参数Initial指定迁移的名称,这是第一次准备数据库迁移的约定名称。

当您运行清单5-8中的命令,Entity Framework Core 检索项目,找到 context 类,并使用它创建迁移。结果会在【解决方案资源管理器】中生成 Migrations 文件夹,它包含准备数据库代码的类文件。

仅仅创建迁移是不够的,它只是一组指令。必须执行这些指令才能创建数据库,以便它能够存储应用程序数据。要执行Initial迁移中的指令,请在 SportsStore 项目文件夹中运行清单5-9中所示的命令。

提示:如果您已经遵照了本章中的示例,并看到一个错误告诉您已经存在一个名为Products的对象,那么在运行清单5-9中的命令之前,请在项目文件夹中运行dotnet ef database drop --force删除数据库。

清单 5-9:应用迁移

dotnet ef database update

Entity Framework Core 将连接连接字符串中指定的数据库服务器,并执行迁移中的语句。结果是一个可用于存储Product对象的数据库。

运行应用程序

对持久存储Product对象的基本支持已经到位,应用程序已经做好了测试的准备,尽管还有一些工作要做。在 SportsStore 项目文件夹下使用dotnet run启动应用程序,导航至 http://localhost:5000,并使用表5-2所示的值在 HTML 表单中创建Product对象 。

表 5-2:创建测试产品对象的值

Name Category Purchase Price Retail Price
Kayak Watersports 200 275
Lifejacket Watersports 30 48.95
Soccer Ball Soccer 17 19.50

为每组数据值单击【Add】按钮,Entity Framework Core 将在数据库中存储对象,结果如图5-2所示。

图5-2 测试数据存储

用户体验保持不变,但在幕后,数据由 Entity Framework Core 存储在数据库中。停止应用程序并使用dotnet run重新启动,您输入的数据仍然可用。

避免查询缺陷

应用程序正在工作,数据存储在数据库中,但要从 Entity Framework Core 中获得最佳效果,还有许多工作要做。特别是,有两个共同的陷阱需要避免。这些问题可以通过检查 Entity Framework Core 发送到数据库的 SQL 查询来识别,在清单5-10中,我在 Home 控制器的Index action 中添加了一条语句,这使得由 HTTP 请求触发的查询更容易被看到。

清单 5-10:Controllers 文件夹下的 HomeController.cs 文件,添加一个控制台语句

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;

namespace SportsStore.Controllers
{
    public class HomeController : Controller
    {
        private IRepository repository;

        public HomeController(IRepository repo) => repository = repo;

        public IActionResult Index()
        {
            System.Console.Clear();
            return View(repository.Products);
        }

        [HttpPost]
        public IActionResult AddProduct(Product product)
        {
            repository.AddProduct(product);
            return RedirectToAction(nameof(Index));
        }
    }
}

System.Console.Clear方法在Index action 被调用时将清空控制台,这样上一次请求所产生的查询将不可见。启动应用程序,导航至 http://localhost:5000,检查显示的日志消息。

注意System.Console.Clear方法仅在您在 PowerShell 或命令提示符中使用dotnet run启动应用程序时有用。如果您试图使用 Visual Studio 调试器启动应用程序,将导致异常。

您将看到有两条日志消息显示了发送到数据库的两个查询,如下所示:

...
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
FROM [Products] AS [p]
...
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
FROM [Products] AS [p]
...

在下面的部分中,我将解释为什么有两个请求,以及其中一个请求没有充分利用数据库服务器的功能。

理解 IEnumerable 缺陷

Entity Framework Core 使得使用 LINQ 查询数据库变得很容易,尽管它并不总是按照您预期的方式工作。在 Home 控制器使用的 Index 视图中,我使用 LINQ Count 方法来确定数据库中存储了多少产品对象,如下所示:

...
@if (Model.Count() == 0)
{
    <div class="row">
        <div class="col text-center p-2">No Data</div>
    </div>
}
else
{
    @foreach (Product p in Model)
    {
        <div class="row p-2">
            <div class="col">@p.Name</div>
            <div class="col">@p.Category</div>
            <div class="col text-right">@p.PurchasePrice</div>
            <div class="col text-right">@p.RetailPrice</div>
            <div class="col"></div>
        </div>
    }
}
...

要确定数据库中存储了多少Product对象,Entity Framework Core 使用 SQL SELECT语句获取所有可用的Product数据,使用该数据创建一系列Product对象,然后对其进行计数。一旦计数完成,Product对象就会被丢弃。

当数据库中只有三个对象时,这不是问题,但随着数据量的增加,以这种方式计算对象所需的工作量就成了一个问题。一种更有效的方法是要求数据库服务器进行计数,这将使 Entity Framework Core 不再需要传输所有数据并创建对象。这可以通过对视图模型类型的简单更改来完成,如清单5-11所示。

清单 5-11:Views/Home 文件夹下的 Index.cshtml 文件,更改视图模型

@model IQueryable<Product>

<h3 class="p-2 bg-primary text-white text-center">Products</h3>

<div class="container-fluid mt-3">
    <!-- ...其它语句省略... -->
</div>

如果重新加载浏览器窗口,您将看到 Entity Framework Core 发送给数据库服务器的两条查询中的第一条已经更改。

...
SELECT COUNT(*)
FROM [Products] AS [p]
...
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
FROM [Products] AS [p]
...

SELECT COUNT查询要求数据库服务器对Product对象进行计数,而不检索数据或在应用程序中创建任何对象。

对不同的视图模型类型进行不同的查询似乎是违反直觉的行为,理解为什么会发生这种情况对于确保 Entity Framework Core 能够高效地查询数据库至关重要。

LINQ 是作为一组扩展方法实现的,它们对实现了IEnumerable<T>接口的对象进行操作。这个接口表示一个对象序列,它由泛型集合类和数组实现。

Entity Framework Core 包括一组 LINQ 扩展方法的副本,它们用于实现了IQueryable<T>接口的对象上。此接口表示一个数据库查询,这些方法的副本意味着数据库中的数据可以像在内存中的对象上一样轻松地执行诸如Count之类的操作。

清单5-4中创建的数据库 context 类中使用的DbSet<T>类实现了这两个接口,因此Products属性实现了IEnumerable<Product>IQueryable<T>接口。当 Index 视图中的视图模型设置为IEnumerable<Product>时,将使用Count方法的标准版本。标准的COUNT实现不理解 Entity Framework Core,只对序列中的对象进行计数。这将触发SELECT查询,并产生效率低下的行为,读取所有数据用于创建对象以进行计数,然后丢弃。

当我将视图模型更改为IQueryable<Product>时,使用了COUNT方法的 Entity Framework Core 版本。该方法的此版本允许 Entity Framework Core 将完整的查询转换为 SQL,并生成更高效的版本,使用SELECT COUNT获取存储对象的数量,而无需检索任何数据。


理解 Razor 视图模型

您可能会感到惊讶,即使存储库类的Products属性的结果是IEnumerable<T>,我也可以将视图模型对象视为IQueryable<Product>。编译视图时,Razor 生成的 C# 类包含对视图模型指定的类型的显式转换,类似于在 action 方法中包含此语句:

public IActionResult Index() 
{
    System.Console.Clear();
    return View(repository.Products as IQueryable<Product>);
}

在本例中,此特性意味着我可以通过更改@Model表达式在使用IQueryable<T>IEnumerable<T>接口之间切换。类型转换是在运行时完成的,这就是为什么在应用程序运行之前,控制器提供的对象与视图所期望的对象之间的任何错配都不会变得明显。


理解重复查询陷阱

提高其中一个查询的效率并不能解释为什么会有两个查询。正如我在上一节中解释的那样,DbSet<T>类实现了IQueryable<T>接口,它表示数据库查询,甚至允许在数据库的数据上使用 LINQ。

默认情况下, Entity Framework Core 在枚举IQueryable<T>对象之前不会执行查询。这允许逐步组合查询,并通过对现有查询调用 LINQ 方法而不是对其返回的数据创建新的查询。但是这种行为也意味着每次枚举IQueryable<T>时都会向数据库发送一个新的 SQL 查询。这对于某些应用程序是有帮助的,因为它意味着您可以使用同一个对象从数据库获取最新数据,但在 ASP.NET Core MVC 应用程序中,这通常会为同一数据生成多个查询,间隔仅几毫秒。

在示例应用程序中,Index 视图中两次枚举IQueryable<T>视图模型对象,如下所示:

@if (Model.Count() == 0)
    {
        <div class="row">
            <div class="col text-center p-2">No Data</div>
        </div>
    }
    else
    {
        @foreach (Product p in Model)
        {
            <div class="row p-2">
                <div class="col">@p.Name</div>
                <div class="col">@p.Category</div>
                <div class="col text-right">@p.PurchasePrice</div>
                <div class="col text-right">@p.RetailPrice</div>
                <div class="col"></div>
            </div>
        }
    }

它不仅仅是枚举对象序列的foreach循环;生成单个结果的 LINQ 方法,如Count方法也将触发查询。IQueryable<T>行为及其在 Index 视图中的使用组合在一起产生两个查询。

既然这两个查询不再相同,情况可能会有所改善,但正如我在下面几节中描述的那样,可能会有进一步的改进。


避免意外查询

从单个IQueryable<T>对象触发多个查询没有什么问题,只要您打算这么做。问题是,当您忘记IQueryable<T>对象的行为时,将它们当作IEnumerable<T>对象来处理,然后意外地在没有注意到的情况下进行查询。在繁忙的应用程序中,意外查询浪费的资源可能很大,并且会增加项目的容量成本。


使用 CSS 避免查询

Index 视图显示了 ASP.NET Coer MVC 应用程序中重复请求的最常见原因之一,其中使用Count方法查看是否存在任何数据,以便向用户显示占位符内容。提供“无数据”占位符的另一种方法是通过依赖 CSS 使其成为浏览器的责任。在清单5-12中,我向示例应用程序中的视图使用的布局中添加了一个style元素,并使用它定义了两个自定义样式。

清单 5-12:Views/Shared 文件夹下的 _Layout.cshtml 文件,定义样式

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>SportsStore</title>
    <link rel="stylesheet" href="~/lib/twitter-bootstrap/css/bootstrap.min.css" />
    <style>
        .placeholder { visibility: collapse; display: none }
        .placeholder:only-child { visibility: visible; display: flex }
    </style>
</head>
<body>
    <div class="p-2">
        @RenderBody()
    </div>
</body>
</html>

分配给placeholder类的 HTML 元素将其visibility属性设置为collapse,默认情况下将其display属性设置为none,这将防止用户看到它。但是,当 HTML 元素是其包含元素的唯一子元素时,属性值将被更改,这是通过使用only-child伪类来实现的。在清单5-13中,我修改了 Home 控制器使用的 Index 视图,以删除对 LINQ Count 方法的调用,转而依赖 CSS 类。

清单 5-13:Views/Home 文件夹下的 Index.cshtml 文件,依赖 CSS 类

@model IQueryable<Product>

<h3 class="p-2 bg-primary text-white text-center">Products</h3>

<div class="container-fluid mt-3">
    <div class="row">
        <div class="col font-weight-bold">Name</div>
        <div class="col font-weight-bold">Category</div>
        <div class="col font-weight-bold text-right">Purchase Price</div>
        <div class="col font-weight-bold text-right">Retail Price</div>
        <div class="col"></div>
    </div>
    <form asp-action="AddProduct" method="post">
        <div class="row">
            <div class="col"><input name="Name" class="form-control" /></div>
            <div class="col"><input name="Category" class="form-control" /></div>
            <div class="col">
                <input name="PurchasePrice" class="form-control" />
            </div>
            <div class="col">
                <input name="RetailPrice" class="form-control" />
            </div>
            <div class="col">
                <button type="submit" class="btn btn-primary">Add</button>
            </div>
        </div>
    </form>
    <div>
        <div class="row placeholder">
            <div class="col text-center p-2">No Data</div>
        </div>
        @foreach (Product p in Model)
        {
            <div class="row p-2">
                <div class="col">@p.Name</div>
                <div class="col">@p.Category</div>
                <div class="col text-right">@p.PurchasePrice</div>
                <div class="col text-right">@p.RetailPrice</div>
                <div class="col"></div>
            </div>
        }
    </div>
</div>

我添加了一个div元素,以便only-child伪类能够工作,并删除if子句及其调用的Count方法。其结果是分配给placeholder类的元素将始终包含在发送给浏览器的 HTML 中,但只有当foreach循环不生成任何元素时才会可见,这将在数据库中没有存储Product对象时发生。如果重新加载浏览器窗口,您将看到现在只有一个查询发送到数据库。

SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
FROM [Products] AS [p]

强制在存储库中执行查询

直接使用IQueryable<T>对象的问题是,如何实现数据存储的细节已经泄漏到应用程序的其他部分,这破坏了 MVC 模式所遵循的功能分离感。


对模式采取平衡的方法

模式是有用的模板,它使得项目的构建易于理解和易于测试,但在实现它们时需要一种平衡的方法。你所采用和忽略模式的哪些部分并不重要 —— 只要你有意识地做出这些决定。

例如,在示例应用程序中,存储库模式旨在隐藏数据存储方式的细节,它在现实与 Entity Framework Core 工作之时存在紧张关系。

通过将IQueryable<T>对象包含到存储库实现类中,我限制了必须了解 Entity Framework Core 查询的应用程序的比例。但是它并没有完全做到这一点,因为应用程序的其他部分仍然必须了解我在清单5-13中定义的主键属性,并且我将在后面的章节中使用它来标识对象。

对我来说,这是实用性(对象必须被唯一标识)和原则性(包含存储库的数据存储细节)之间的合理平衡。您可能倾向于完全避免使用存储库,或者选择更加严格地坚持存储库模式(例如,使用不同的主键策略,如第19章所述)。


另一种方法是让存储库实现类负责处理IQueryable<T>对象的怪癖,并向应用程序的其余部分提供常规的内存对象集合,它们实现了IEnumerable<T>接口,并且可以在不担心意外影响的情况下进行枚举。在清单5-14中,我更改了存储库类,使它不再传递 context 类Products属性返回的DbSet<T>对象。

清单 5-14:Models 文件夹下的 DataRepository.cs 文件,强制查询计算

using System.Collections.Generic;
using System.Linq;

namespace SportsStore.Models
{
    public class DataRepository : IRepository
    {
        //private List<Product> data = new List<Product>();
        private DataContext context;

        public DataRepository(DataContext ctx) => context = ctx;

        public IEnumerable<Product> Products => context.Products.ToArray();

        public void AddProduct(Product product)
        {
            this.context.Products.Add(product);
            this.context.SaveChanges();
        }
    }
}

LINQ 的ToArrayToList方法触发查询的执行,并生成包含结果的数组或列表。它们是内存中的常规对象集合,仅实现了IEnumerable<T>接口,这意味着我必须再次更改 Home 控制器使用的 Index 视图中的视图模型,如清单5-15所示。这也意味着我可以安全地返回到视图中执行多个 LINQ 操作,而不必考虑数据是如何获得的。

注意:这种方法的一个结果是,存储库必须能够向应用程序的其余部分提供它所需的数据,这将导致复杂的查询被合并到存储库类中。我倾向于采用这种方法,因为它使 Entity Framework Core 必须处理的所有查询更容易查看和管理。但这只是我个人的喜好,你应该选择最适合你的方法。

清单 5-15:Views/Home 文件夹下的 Index.cshtml 文件,更改视图模型

@model IEnumerable<Product>

<h3 class="p-2 bg-primary text-white text-center">Products</h3>

<div class="container-fluid mt-3">
    <div class="row">
        <div class="col font-weight-bold">Name</div>
        <div class="col font-weight-bold">Category</div>
        <div class="col font-weight-bold text-right">Purchase Price</div>
        <div class="col font-weight-bold text-right">Retail Price</div>
        <div class="col"></div>
    </div>
    <form asp-action="AddProduct" method="post">
        <div class="row">
            <div class="col"><input name="Name" class="form-control" /></div>
            <div class="col"><input name="Category" class="form-control" /></div>
            <div class="col">
                <input name="PurchasePrice" class="form-control" />
            </div>
            <div class="col">
                <input name="RetailPrice" class="form-control" />
            </div>
            <div class="col">
                <button type="submit" class="btn btn-primary">Add</button>
            </div>
        </div>
    </form>
    <div>
        @if (Model.Count() == 0)
        {
            <div class="row">
                <div class="col text-center p-2">No Data</div>
            </div>
        }
        else
        {
            @foreach (Product p in Model)
            {
                <div class="row p-2">
                    <div class="col">@p.Name</div>
                    <div class="col">@p.Category</div>
                    <div class="col text-right">@p.PurchasePrice</div>
                    <div class="col text-right">@p.RetailPrice</div>
                    <div class="col"></div>
                </div>
            }
        }
    </div>
</div>

使用dotnet run重启应用程序,并导航至 http://localhost:5000 ;您将看到与之前相似的输出。在本节中,用户体验并没有改变,但是如果您检查应用程序生成的日志消息,您将发现数据库只有一个查询,即使视图模型对象被枚举了两次。

SELECT [p].[Id], [p].[Category], [p].[Name], [p].[PurchasePrice], [p].[RetailPrice]
FROM [Products] AS [p]

常见问题及解决办法

一旦基本功能到位,使用 Entity Framework Core 来存储和检索数据是非常简单的,但也有一些陷阱需要避免。在接下来的部分中,我将描述您最可能遇到的问题,并解释如何解决这些问题。

创建或访问数据库时遇到的问题

在尝试创建数据库或从应用程序访问数据库时,会出现最基本的问题在大多数情况下,都是由错误配置引起的,正如我在下面几节中所解释的那样。

“No executable found matching command dotnet-ef” 错误

dotnet ef命令用于创建和管理迁移,但它们默认不启用,而是依赖于添加到应用程序中的包。如果您在试图运行任意dotnet ef命令时收到“no executable found”错误,请打开 .csproj 文件确认存在DotNetCliToolReference项用于引用Microsoft.EntityFrameworkCore.Tools.DotNet包,如清单5-1所示。

如果添加了包,那么确保在项目文件夹中运行命令,该文件夹包含 .csproj 文件和 Startup.cs 文件。如果尝试在任何其他文件夹中使用dotnet ef,则 .NET Core 运行时将无法找到所使用的命令。

“Build Failed” 错误

运行dotnet ef命令时会自动编译项目,如果代码中有任何问题,则报告“build failed”错误,但未提供问题原因的详细信息。

如果要查看是什么阻止编译器生成项目,请在项目文件夹中运行dotnet build。然后,您可以解决问题并再次运行dotnet ef命令。

注意:在命令提示符或 PowerShell 窗口中使用dotnet ef启动应用程序之后,在另一窗口中尝试运行dotnet ef命令也会导致此错误。生成过程试图覆盖正在运行的应用程序打开的文件,这将导致失败。停止应用程序,您的dotnet ef命令就会成功。

“The entity type requires a primary key to be defined” 错误

如果在尝试创建迁移时看到此错误,则最有可能的原因是您没有选择主键。对于简单的应用程序,最好的方法是清单5-3所示的方法。对于复杂的应用程序,使用主键的高级特性将在第19章中描述。

“There is already an object named in the database” 异常

当尝试创建已经存在的数据表的迁移时,会发生此异常。通常,当您从项目中移除迁移,重新创建它,然后尝试将其再次应用到数据库时,就会发生这种情况。数据库已经包含了迁移创建的表,这阻止了迁移的成功。

这个问题最有可能出现在开发过程中,最简单的解决方案是通过在项目文件夹中运行清单5-16中的命令来删除和重新创建数据库。它们将删除数据库及其包含的数据,这意味着它不应用于生产系统。

清单 5-16:重置数据库

dotnet ef database drop --force
dotnet ef database update

“A Network-Related or Instance-Specific Error Occurred” 异常

此异常告诉您 Entity Framework Core 已经无法连接数据库服务器。此异常最见的原因是 appsettings.json 文件中连接字符串的错误。如果您正使用 LocalDB 开发,那么确保已经将Server属性配置为(localdb)//MSSQLLocalDb,它有两个/字符,并且名字的第二部分是MS_SQL_Local_Db(只是没有下划线字符)。如果您正使用的是完整的 SQL Server 产品 —— 或另一个数据库服务器 —— 那么确保已经使用了正确的主机名和端口,主机名可以解析为正确的 IP 地址,并且测试网络以确保可以到达服务器。

“Cannot Open Database Requested By The Login” 异常

如果收到此异常,那么 Entity Framework Core 可以与数据库服务器通信,但请求所访问的是一个不存在的数据库。首先要检查 appsettings.json 文件,确保已经在连接字符串中指定了正确的数据库名称。对于 LocalDB(以及完整版 SQL Server 产品)来说,这意味着正确设置了Database属性,如清单5-6所示。如果您使用的是不同的数据库,那么查看文档以确定数据库名称是如何指定的。

提示:正确指定所包含的连接字符串是困难的,尤其当您切换数据库服务器或提供程序包时。网站https://www.connectionstrings.com为大量数据库服务器以及连接选项提供了一个有用的参考。

如果您已经输入了正确的数据库名称,则可能是创建了一个迁移但没有应用它,这意味着数据库服务器重来没有创建过 Entity Framework Core 所访问的数据库。在项目文件夹中运行dotnet ef database update以应用迁移。

查询数据时遇到的时间

查询数据时的最大问题是重复请求数据库服务器,如《避免查询缺陷》这一节所述。但如我在之后部分所述,这并不是惟一的问题。

“Property Could Not Be Mapped” 异常

当您在数据模型类中添加了一个属性,但没有创建或应用一个迁移以更新数据库时,发生此异常。参考第13章获取如何使用迁移保持数据模型及数据库同步的详细信息。

“Invalid Object Name” 异常

此异常的出现意味着 Entity Framework Core 试图查询数据库中不存在的表的数据。这是上一小节所描述问题的变体,通常意味着数据库没有更新以反映应用程序中数据模型的更改。参考13章获取迁移是如何工作的,以及如何管理它们的详细信息。

“There is Already an Open DataReader” 异常

当您在未读取完前一次查询的结果时试图开始一个新的查询时,则引发此异常。如果您正使用 SQL Server,可以在连接字符串中启用多活动结果集(MARS multiple active result set)功能。对于其它数据库,您可以使用ToArrayToList方法强制一个查询在完全读取之后才能开始下一个查询。

“Cannot Consume Scoped Service from Singleton” 异常

Startup类中的AddDbContext方法使用AddedScoped方法为 context 类设置依赖注入。这意味着您必须使用如清单5-7所示的AddTransientAddScoped方法配置依赖于 contex 类的所有服务,如存储库实现类。如果使用AddSingleton方法注册您的服务,当 ASP.NET Core 试图解析依赖时,将收到一个异常。

陈旧的 Context 数据问题

在 ASP.NET Core MVC 应用程序中,Entity Framework Core 期望为每个 HTTP 请求创建一个新的 context 对象。一个常见的问题是保持 context 对象并将它们用于之后的请求中。

这带来的问题的是每个 Entity Framework Core context 对象都会跟踪它为使用缓存和检测更改而创建的对象。保持 context 对象并重用它们可能会产生意外的结果,因为数据已经过时或不完整。即使您可能不愿意为单个请求创建对象,但它已经是应用程序其余部分使用的模式 —— MVC 框架为每个 HTTP 请求创建新的控制器和视图对象 —— 这也是 Entity Framework Core 期望它的上下文对象被使用的方式。

存储数据出现的问题

在大多数情况下,允许 Entity Framework Core 在 MVC 数据模型中存储类实例所需的更改很少。下面我会描述一些常见的问题。

对象没有被存储

如果应用程序看起来工作正常,但对象没有被存储进数据库中,第一件事是检查您是否记得在存储库实现类中调用了SaveChanges方法。Entity Framework Core 仅在调用了SaveChanges方法后才会更新数据库,如果您忘记了,就会默默地放弃更新。

仅有一些属性值没有被存储

如果与对象关联的数据值仅存储在数据库中,那么请确保您只使用了属性,并且所有属性有具有setget访问器。Entity Framework Core 将只存储属性值,默认情况下将忽略所有方法或字段。如果应用程序的约束限制了您仅在数据模型类中使用属性,那么请参阅第20章,了解高级 Entity Framework Core 特性,以更改数据模型类的使用方式。

“Cannot Insert Explicit Value for Identity Column” 异常

如果您已经如清单5-3那样选择了一个主键,Entity Framework Core 将配置数据库,以便数据库服务器负责生成允许唯一标识对象的值。

这意味着多个应用程序 —— 或相同应用程序的实例 —— 可以在没有协调的情况下共享同一数据库,而避免重复的值。它还意味着,如果尝试使用的不是键类型的默认值存储一个新对象,则会引发异常。对于Product类来说,主键类型是long,因此,只有当Id值为 0 时才能存储新对象,这是缺省的long值。此异常最常见的原因是在视图中包含一个input元素,用于创建新对象并允许用户提供一个值,然后由 MVC 模型绑定器使用该值并通过 Entity Framework Core 传递给数据库。

提示:如果不希望数据库服务器为您生成值,请参见第19章中的高级主键选项。

总结

本章,我添加了对在数据库中存储数据及查询数据的支持。我解释了迁移到持久数据存储的过程,并演示了应用程序所做的查询必须如何调整以有效地与 Entity Framework Core 一起工作。我还描述了在将 Entity Framework Core 引入现有应用程序时可能遇到的最常见问题,并告诉您如何解决这些问题。在下一章中,我将添加功能到 SportsStore 应用程序,以修改和删除数据库中的数据。

;

© 2018 - IOT小分队文章发布系统 v0.3